Promise
在日常中经常用到,并且也能够熟练使用:
1 | new Promise((resolve) => { |
上面代码会依次打印a、c、b
,对此我们都毫无疑义。
但是为什么呢?我们能自己实现一个Promise
库吗?
「错误」的实现
不参考任何教程、代码,只从上面的结果推理出Promise
是什么样的,或许正确,或许错误。但这也是最有趣的地方,完成后可以与其他Promise
库对照,差距究竟差在哪。
读者,也就是你,如果真的想对
Promise
深入了解,更正确的做法也是自己手撸一个,而不是从各种「二手信息」(包括此文)中学习。
话不多说,开始探索之旅。
Promise 类
首先分析上面的代码,很容易看出是先调用new
关键字生成Promise
实例,并执行传入的参数,假设形参是fn
。
然后调用得到的实例上的then
方法,也传入一个参数,该参数也会被调用,假设该形参为resolved
。
所以Promise
类应该是这样的:
1 | // 用 FakePromise 避免覆盖原生的 Promise |
constructor
而我们又知道,在调用new
关键字时,马上就会打印a、c
,这表示传入的参数fn
被立即调用了。
1 | constructor(fn) { |
并且该函数有形参resolve
会被调用时使用,所以在调用fn
时还要传入实参resolve
。
1 | constructor(fn) { |
那么问题来了,这个resolve
实参是哪里来的?或许是在全局的一个函数?那么试试看好了:
1 | function resolve() { |
使用开始的实例测试一下:
1 | new FakePromise((resolve) => { |
bingo!没有报错就是好的开始,好的开始就是成功了一半~~~
then 方法
继续,我们从开始的例子打印的结果a、c、b
可以知道在调用then
方法后,也会调用传入的参数resolved
,并且还接受一个参数,该参数为constructor
内resolve
的实参。
1 | class FakePromise { |
整体观察一下,在「1」调用了「2」,此时「2」是可以拿到b
这个参数的,是否可以将参数保存起来作为全局变量,在「3」处使用呢?
1 | let globalParam = null; |
再实际测试一下,成功打印a、c、b
!!实际的Promise
就这么简单吗?
用复杂些的例子测试:
1 | new FakePromise((resolve) => { |
结果是'a'、'c'、null
,此时流程是这样的(按执行顺序):
resolved
在resolve
前执行,导致globalParam = param
没有执行,所以传入的是null
。
then 与 resolve 的顺序
问题出在哪里呢?
可以想到,必须要resolve('b')
执行完后,才能调用resolved
。
resolved: hi,resolve,执行了吗?
resolve: 还没呢,再等会,要不然我通知你吧,不然你每隔一秒就来问也挺累的。
resolved: 那成,麻烦你了啊老铁
按照这个思路,修改下代码,如下:
1 | let globalResolved = null; |
在then
方法内,不立即执行传入的resolved
了,而是保存起来,等待resolve
执行完成后再调用,就实现了「resolve
通知resolved
」。
实际测试发现在打印'c'
后延迟 1s 后打印了'b'
。
但是发现使用开始的例子测试又出问题了:
1 | new FakePromise((resolve) => { |
只打印了'a'、'c'
,这是因为先执行resolve('b')
后才执行resolved()
。
用上面的例子来说,就是resolve
想要通知resolved
时发现resolved
还没出现。。。
resolve: 老铁,你要的参数来了,老铁呢?
「成功」的实现
如果这样,还要判断then
是否执行?
捋一捋,首先我们可以认为两个函数是同时执行的(同一个 task)
- constructor
- then
当调用then
的实参resolved
时,constructor
的实参fn
内可能还有函数setTimeout
在执行。
所以是全局resolve
的调用时间不确定,只能在它调用时去通知resolved
,并且还要判断resolved
是否存在。
思路没有问题,回顾下之前的实现:
1 | // 第一次的尝试,由 then 的参数 resolved 通知 |
如果把这两者结合起来呢?
1 | let globalParam = null; |
思路就是分两种情况,
1、假设constructor
内的resolve
先执行了,此时还没有globalResolved
,就保存param
,即上面代码的「1」。
然后会执行到then
方法中的resolve(resolved)
,「2」「3」处的条件为真,成功。
2、假设先执行了then
的resolve(resolved)
,能够通过「2」的条件判断,但是无法通过「3」,所以保存了参数到globalResolved
变量中。然后执行到了constructor
内的resolve
,「4」条件为真,也能够成功打印。
总之resolve
必须要被调用两次。
代码在两种情况下都能够成功打印需要的结果,但仔细思考,如果constructor
内的resolve
参数是一个函数呢?
1 | new FakePromise((resolve) => { |
各种错误
脑洞告一段落,虽然「实现」了需要的功能,但实际上并没有按照规范来,比如我们都知道Promise
在执行过程中是有「状态」的,并且必然是以下其中一种状态
- Pending
- Fulfilled
- Rejected
参考 Bare bones Promises/A+ implementation 发现核心原理和上面的类似,除了一些潜在的bug
、没有状态等等,一个最大的问题是代码都是同步执行,即在一个 task 中,而规范要求
实践中要确保 onFulfilled 和 onRejected 方法异步执行,且应该在 then 方法被调用的那一轮事件循环之后的新执行栈中执行。
举个例子,下面的代码执行结果是什么?
1 | console.log('start'); |
先仔细思考下。
1 |
|
在使用自己实现的FakePromise
时,输出为:'start'、'a'、'b'、'c'、'end'
。
将上面代码的FakePromise
改为原生的Promise
即可查看到正确的输出为:'start'、'a'、'c'、'end'、'b'
。
总结
其实Promise
也没有想象的难,花一些时间就能够实现。但相比实现了,更重要的是探索的过程,以及对于「规范」的了解。
下一篇将FakePromise
改造成「正确」的Promise
实现,敬请期待~
对于上面答案有疑义的可以看 Tasks, microtasks, queues and schedules(译) 这篇。